V8 对象属性存储:快属性与慢属性
在 JavaScript 中,对象是属性的集合。但 V8 为了优化不同场景下的属性访问,在底层将属性分为了多种存储模式。
一、一句话先记住
决定属性存哪里的,不是访问语法(. 或 []),而是“键的类型”。
- 数字键 (0, 1, 2...) → 存放在 Elements (索引属性)
- 字符串键 ("a", "b"...) → 存放在 Properties (命名属性)
二、V8 对象结构图解
V8 将对象的属性分为 Elements (索引属性) 和 Properties (命名属性),并针对它们分别存储。
1. 内存里的真相 (以代码为例)
假设我们有如下对象:
javascript
const obj = {
a: 1,
b: 2,
2: "hello"
};- 隐藏类 (Map/HiddenClass):
- 只记录命名属性:
a → offset 0,b → offset 1。 - 不记录数字索引属性。 👉 详见:隐藏类原理
- 只记录命名属性:
- Elements (索引属性):
- 采用 线性数组 存储:
[ , , "hello"]。 - 索引 2 的位置直接存放值。
- 采用 线性数组 存储:
- Properties (命名属性):
- 采用 线性数组 (快属性模式下) 存储:
[1, 2]。 a的值 1 在 offset 0,b的值 2 在 offset 1。
- 采用 线性数组 (快属性模式下) 存储:
三、访问时到底发生了什么?
| 访问语法 | 键的类型 | V8 判定逻辑 | 查找路径 |
|---|---|---|---|
obj.a | 字符串 "a" | 命名属性 | 查隐藏类偏移量 → 去 Properties 拿值 |
obj["b"] | 字符串 "b" | 命名属性 | 查隐藏类偏移量 → 去 Properties 拿值 |
obj[2] | 数字 2 | 索引属性 | 不看隐藏类 → 直接去 Elements 拿值 |
obj["2"] | 字符串 "2" | 自动转为数字 | 同上,走 Elements 路径 |
👉 核心点:. 和 [] 只是语法糖。真正决定走哪条路的是键的类型。字符串键通常走 Properties,数字键(或可转为数字的字符串)走 Elements。
四、深入理解:快属性 (Fast) vs 慢属性 (Slow)
快属性和慢属性,本质上只有一件事不同:Properties 里的属性是怎么存的。
1. 先忘掉“快 / 慢”,换个直观的名字
| 官方叫法 | 你可以叫它 | 存储形式 | 是否依赖隐藏类 |
|---|---|---|---|
| 快属性 (Fast) | 数组式属性 | 线性数组 [值, 值] | ✅ 是 |
| 慢属性 (Slow) | 字典式属性 | 哈希表 {"a": 1} | ❌ 否 |
2. 快属性 (数组式) —— 到底“快”在哪?
1️⃣ 前提条件
- 属性数量不多。
- 没有使用
delete。 - 属性添加顺序稳定。 👉 满足以上条件,V8 就会优先使用快属性。
2️⃣ 内存里长什么样?
- HiddenClass: 记录
a → offset 0,b → offset 1。 - Properties: 存储为一个纯数组
[1, 2]。
3️⃣ 访问逻辑
当执行 obj.a 时:
- 找到
obj.map(隐藏类)。 - 查表得知
a的偏移量是 0。 - 直接取
properties[0]。 ✅ 结论:像数组下标访问一样快,不需要字符串比较。这就是“快”的真正含义。
3. 慢属性 (字典式) —— 到底“慢”在哪?
1️⃣ 什么时候会变成慢属性?
- 使用
delete:删除非末尾属性会破坏隐藏类的线性结构。 - 属性太多:当属性数量超过一定阈值(通常是 30 个)。
- 属性名不固定:频繁使用动态键名。 👉 此时,隐藏类保不住了,V8 会将其降级。
2️⃣ 内存里长什么样?
- V8 会放弃隐藏类,直接将
Properties转换为一个 哈希表 (字典)。
3️⃣ 访问逻辑
当执行 obj.a 时:
- 计算字符串
"a"的 Hash 值。 - 去哈希表中查找对应的 Key。
- 拿到 Value。 ❌ 结论:有 Hash 计算、有冲突处理、比数组慢得多。这就是“慢”的真正含义。
4. 正确的“内存心智模型”
text
JSObject
├── map → HiddenClass (快属性时有用)
├── elements → [ 索引属性 ]
└── properties
├── 快属性模式 → [值, 值, 值] (线性数组)
└── 慢属性模式 → { "a": 值, "b": 值 } (哈希字典)✅ 快属性依赖隐藏类 | ✅ 慢属性不依赖隐藏类
5. 为什么 V8 不一直用快属性?
JavaScript 是极其动态的,如果强行对成千上万个属性或频繁 delete 的对象使用快属性,会导致:
- 隐藏类爆炸:每个对象都可能产生一堆中间态隐藏类。
- 内存浪费:线性数组扩容和维护成本高。 👉 V8 的策略是:能快就快,快不了就果断变慢。
五、为什么要区分 Elements 和 Properties?
主要原因在于 属性遍历顺序 的规范要求:
- ECMAScript 规范要求:
- 所有的索引属性按升序排列。
- 所有的命名属性按添加顺序排列。
- V8 的策略: 为了满足规范并保持高效,V8 将它们分开存储。索引属性存储在
Elements数组中,命名属性存储在Properties数组或对象内。
六、关联知识
为了更深入地理解 V8 属性访问优化,建议阅读以下文档:
- [V8 引擎核心概念综述](file:///e:/vue/interview-guide/docs/前端/31.浏览器/00.V8 引擎核心概念综述.md):V8 整体架构图。
- V8 隐藏类与内联缓存:深入理解 Map (Hidden Class) 的原理。
- V8 内存布局与堆栈管理:了解堆内存的分区。
七、高频面试题
1. 解释 V8 中的 Elements 和 Properties 有什么区别?
回答:
- Elements: 专门存储数组索引属性。为了满足升序排列的要求,V8 使用线性数组存储,通过索引直接定位,效率极高。
- Properties: 存储非索引的命名属性。它们又分为对象内属性(最快)、快属性(线性存储)和慢属性(哈希存储)。
2. 为什么在 JS 中属性的遍历顺序有时是固定的,有时又不是?
回答:根据规范,索引属性(Elements)总是按数值升序遍历,而命名属性(Properties)按添加顺序遍历。V8 底层将它们分开存储就是为了高效地实现这一规范要求。
3. 什么情况下对象会从“快模式”进入“慢模式”?
回答:
- 大量添加属性: 当命名属性数量超过一定阈值。
- 使用 delete: 删除非最后添加的属性会破坏隐藏类的线性结构。
- 属性名不固定: 频繁使用动态键名。 一旦进入慢模式(字典模式),属性访问将变慢,且难以恢复到快模式。
4. 如何优化对象属性的访问性能?
回答:
- 构造函数初始化: 在构造函数中一次性初始化所有属性。
- 顺序一致: 保持不同实例的属性添加顺序一致,以复用隐藏类。
- 避免 delete: 如果需要移除属性,建议将其设为
null或undefined。 - 使用数组存储索引数据: 尽量不要将索引作为普通对象的键,而是使用真正的数组。